在寫遊戲流程的時候,常常會遇到需要等待某件事情發生,接著再去做另一件事的情況。比如說城內守衛要巡邏,那是不是要先規畫好巡邏站一二三,然後先設定目標為第一站,逛逛逛,等逛到了第一站,再變更目標為第二站。逛到了第二站,再改目標到第三站。到了第三站,再改回目標第一站。
以上的流程,在2015年以前的JavaScript裏,必須藉由把回呼函式(callback)當成參數傳進函式來達成非同步的任務執行。
/** 這裏寫一個控制角色前往某處的示意函式
* actor: 角色,假設actor有location屬性及goto(location)方法
* location: 目標位置
* callback: 走到目標後,要呼叫的函式
*/
function gotoLocation(actor: Actor, location: Point, callback: Function) {
// 控制角色前進
actor.goto(location);
/** 用setInterval,每秒檢查一次目前位置
* setInterval會回傳一個收據
* 之後我們要用這個收據停止setInterval繼續運作
*/
let intervalID = setInterval(
// 每一秒要執行的函式
function() {
// 檢查角色的位置是不是和目標位置一樣
if(actor.location.equals(location)) {
// 停止這個interval繼續執行
clearInterval(intervalID);
// 回呼指定的函式
callback();
}
},
1000 // 1000毫秒
);
}
// 先建立守衛角色
let guard = new Actor();
// 決定巡邏站
let patrolLocs = [
new Point(10, 10),
new Point(20, 10),
new Point(15, 20),
];
// 開始巡邏的函式, 參數是角色和下一站的index
function gotoNextLocation(actor: Actor, nextPatrolIndex: number) {
// 把下一站的index取巡邏站數量的餘數,不讓index超出範圍
nextPatrolIndex %= patrolLocs.length;
// 前往下一站
gotoLocation(
actor, // 角色
patrolLocs[nextPatrolIndex], // 前往目標
function() { // 到達目標後要執行的回呼函式
// 呼叫自己,去下一站
gotoNextLocation(actor, nextPatrolIndex + 1);
}
);
}
// 出發
gotoNextLocation(guard, 0);
在上面的示範程式中,gotoNextLocation()在到達目標巡邏站之後,會再次呼叫自己,只不過其中的nextPatrolIndex(下一站的位置)被往前推進到下一站了。
Promise的概念可能上個世紀就出現了,不過在JavaScript裏是到了2015年ECMAScript 6 (ES6)標準完備後才有的。Promise會承諾你,不管它執行的成功與否,都會給你一個交待,只不過不一定在當下就能告訴你結果。
如果一個函式不是馬上就能完成任務,那麼就可以回傳一個Promise,給呼叫函式的人一個承諾。建立承諾時,會從承諾那裏得到兩個工具函式,resolve(解決)和reject(駁回)。當函式的工作順利完成後,建立Promise的人必須要遵守承諾去呼叫resolve(),當resolve()被呼叫的時候,當初得到這個承諾的人,就會收到這則消息。當工作失敗出現錯誤時,也同樣要遵守承諾呼叫reject(),讓當初得到這個承諾的人,知道這個工作失敗了。
如果給了承諾,卻忘了呼叫resolve()或reject(),那麼就違反了Promise的精神,整個程式就很有可能因此出現Bug。
相對的,得到Promise的人會處在等待的狀態,但在開始等待前,可以利用兩個方法去登記承諾兌現時要做的事。一個是承諾提供的then(function),在Promise所代表的工作完成後,會呼叫使用then()所指定的回呼函式。另一個是承諾提供的catch(function),在Promise所代表的工作失敗時,會呼叫使用catch()所指定的函式。大致的使用方法如下。
// 假設Man有一個非同步函式:買房(),會回傳一個Promise
let man = new Man();
man.買房()
// 買房()回傳一個Promise,我們利用它提供的.then(function)
// 來設定買房成功後要執行的函式
.then(function() { 結婚(); })
// 也可以利用Promise.catch處理出錯之後的應對函式
.catch(function(失敗原因) { 分手("不是因為"+失敗原因); })
以上的範例中,不管是在 買房() 的過程出了差錯,或是 結婚() 時發生了什麼意外,都會往下找到第一個用.catch(function)指定的函式來處理錯誤。
我們把剛剛守衛巡邏的程式,改用Promise來實作看看。
/** 這裏寫一個控制角色前往某處的示意函式
* actor: 角色,假設有actor.location這個屬性
* location: 目標位置
* 回傳一個不需要結果資料的Promise
*/
function gotoLocation(actor: Actor, location: Point): Promise<void> {
// 建立並回傳一個Promise
return new Promise<void>(
// Promise的建構子要給一個函式
// Promise會藉由這個函式給我們resolve和reject這兩個東西
function(resolve, reject) {
// 控制角色前進
actor.goto(location);
/** 用setInterval,每秒檢查一次目前位置
* setInterval會回傳一個收據
* 之後我們要用這個收據停止setInterval繼續運作
*/
let intervalID = setInterval(
// 每一秒要執行的函式
function() {
// 檢查角色的位置是不是和目標位置一樣
if(actor.location.equals(location)) {
// 停止這個interval繼續執行
clearInterval(intervalID);
// 執行resolve,表示這個承諾的任務完成了
resolve();
}
},
1000 // 1000毫秒
);
}
);
}
// 建立守衛角色
let guard = new Actor();
// 決定巡邏站
let patrolLocs = [
new Point(10, 10),
new Point(20, 10),
new Point(15, 20),
];
// 開始巡邏的函式, 參數是角色和下一站的index
function gotoNextLocation(actor: Actor, nextPatrolIndex: number) {
// 把下一站的index取巡邏站數量的餘數,不讓index超出範圍
nextPatrolIndex %= patrolLocs.length;
// 前往下一站,接收承諾
let promise = gotoLocation(actor,patrolLocs[nextPatrolIndex])
// 用.then來決定承諾兌現後要做什麼事
promise.then(
function() {
// 去下一站
gotoNextLocation(actor, nextPatrolIndex + 1);
}
);
}
// 出發
gotoNextLocation(guard, 0);
Promise.then(function)還會把function裏回傳的結果包裝成Promise再傳出來,所以能有Promise接龍的寫法。示範一次給大家看。
// 控制守衛按路線前往三個地方
gotoLocation(guard, new Point(10, 10))
.then(() => gotoLocation(guard, new Point(20, 10))
.then(() => console.log("還剩一站"))
.then(() => gotoLocation(guard, new Point(15, 20))
.then(() => console.log("呼~可以休息了"))
上面的範例中,當第一次gotoLocation結束後,會執行第二行給的箭頭函式。這個箭頭函式會回傳另一個gotoLocation丟出來的承諾,所以第三行的.then()是針對第二次gotoLocation所給出的承諾作反應。第三行的console.log()雖然沒有回傳東西,但第三行的.then()仍會把這個結果包裝成Promise再傳給第四行使用。
這樣的寫法,是不是有點像我們講話的順序:「前往10,10」➜「.然後(前往20,10)」➜「.然後(講講話)」➜「.然後(前往15,20)」➜「.然後(再講會兒話)」,因此大大提高了程式碼的可讀性。
2017年JavaScript的標準來到了ECMAScript 2017(ES8),在這一版的標準中加入了非同步函式兩個超酷的關鍵字,async以及await,這兩個關鍵字進一步加強了程式碼的可讀性。
如果一個函式是非同步的,也就是它回傳的值是一個Promise,那麼我們在呼叫這個函式時,就可以用await關鍵字來等待。程式在await的時候不會繼續往下執行,而會等到該函式的任務執行完畢(承諾兌現),才會再繼續往下走。
我們再把剛剛的程式碼,改用await來試式。
// gotoLocation函式不變,這邊就不重覆寫了
...
/** 開始巡邏的函式, 參數是角色和下一站的index
* 由於函式裏有用到await,
* 代表這個函式也是非同步(不會馬上完成的函式)
* 所以要用async關鍵字宣告為非同步函式
*/
async function gotoNextLocation(actor: Actor, nextPatrolIndex: number) {
while(true) {
// 把下一站的index取巡邏站數量的餘數,不讓index超出範圍
nextPatrolIndex %= patrolLocs.length;
// 執行gotoLocation,並等它執行完畢
await gotoLocation(actor,patrolLocs[nextPatrolIndex]);
// 目標改到下一站
nextPatrolIndex++;
}
}
// 出發
gotoNextLocation(guard, 0);
小哈知道第一次看到非同步函式,同學們應該都嚇壞了。不過在認識、理解、運用熟練之後,我想任何人都會愛上它的。同學們可以從今天的示範程式中去揣摩非同步函式的意義與流程,再慢慢研究Promise/async/await更深入的應用。
上面的例子中並沒有說明Promise.reject()的應用實例,因為擔心一次講太多,會給同學們留下陰影。今天的最後,稍微講一下如何使用在建立Promise時得到的reject,以及使用Promise.catch()怎麼接住錯誤訊息。
我們寫一個除法的函式,這個函式會回傳一個Promise,並且在除數為0的時候,以reject()來告訴得到承諾的人『除數不可以是0喔!』
/** 處理兩數相除的非同步函式
* 參數是兩個數字,到時會計算 num1 / num2 的結果
* 回傳一個帶有數字結果的Promise
*/
function devide(num1: number, num2: number): Promise<number> {
// 建立Promise, 同學們最好早點習積箭頭函式~
return new Promise((resolve, reject) => {
if(num2 == 0) {
// 除數不能是0,我們呼叫reject來拒絕執行這個任務
reject("除數不可以是0喔!");
} else {
let result = num1 / num2;
resolve(result);
}
});
}
// 用來做點事的函式
async function doSomething() {
try {
let result = await devide(1, 0);
console.log(`結果 = ${result}`);
} catch (error) {
console.log(`糟糕!有錯: ${error}`);
}
}
// 做點事吧
doSomething();
因為我們給devide函式的第二個參數是0,所以這段程式在執行時會出錯並且被函式reject()。我們用try catch包住devide()就可以攔截到這則錯誤,錯誤訊息會藉catch的參數error傳給我們。
如果使用ES6的寫法,上面的例子就會變成如下的程式。
// devide函式和上面寫的一樣
function devide(num1: number, num2: number): Promise<number> {
...
}
// 執行函式
devide(1, 0)
.then((result) => {
console.log(`結果 = ${result}`);
})
.catch((error) => {
console.log(`糟糕!有錯: ${error}`);
});
CG示範專案的錯誤處理範例
這段程式寫在專案的測試檔裏,所以要執行這段程式,請將『試玩遊戲』的按鈕切換為『模組測試』。